BlueSky Airline: Single Leg Revenue Managment

A simulation study on protection level & overbooking

# Table of Contents

Table of Contents

# Executive Summary

This simulation study aims to explore the optimal decisions for some of the most common capacity allocation challenges in airline revenue management faced by a virtual carrier, BlueSky Airline. Prescriptive models are developed by simulating the uncertainties in demand and show-up probabilities for a single-leg flight with two distinct fare classes (low- and full-fare) and incorporting realistic scenarios in the airline's reservation system. Specifically, we examine the effects of buy-up, no-show and buy-down behavior on the optimal protection level of full-fare tickets and the system overbooking limit set by BlueSky to maximize expected profit. In a baseline situation where demand is the sole source of uncertainty, the optimal protection level is found to center around the mean of full-fare demand distribution. When buy-up and no-show behaviors are considered, the optimal protection level is raised in response to harness the potential increase in full-fare demand. An optimal overbooking limit is also calculated to minimize the revenue lost by overselling a proportion of the total capacity. Lastly, when buy-down is incorporated, the scenario is complicated by multiple sources of variation and the optimum expected profit is reduced by a noticeable amount (~10%) as buy-down behavior drives down full-fare profit. Although a set combination of best protection level and overbooking limit cannot be concluded for certain due to the nature of simulation modeling, the results of this study suggest that the overbooking limit should increase as protection level increases. We propose that there is such existence of a "golden ratio" between protection level and overbooking limit that maximizes profit in every optimal cominbation generated by the model. BlueSky is advised to use the developed simulation models with updated input parameters according to their demand estimation and employ any of the top combinations suggested for a profit optimizing effect.

# Introduction

Since the passage of the Airline Deregulation Act in 1978, revenue management has been a key factor contributing to the long-term success of airline companies as these airlines became less restricted in updating pricing, booking level and service terms to optimize the profitability of their flights. Being able to “sell the right tickets to the right customers at the right price” through means of demand forecasting, dynamic pricing and capacity allocation simulations has become the central focus in most revenue management strategies. In the airline industry, a combination of constraints are often embedded in the ticket reservation system to serve as control mechanisms for ticket availability and in turn play a crucial role in revenue maximization. For instance, the airline may set booking limits to control the capacity of any given class of seats to be sold at any given time, thus potentially increasing the net profit by securing seats at higher price levels. Protection levels work in a similar fashion, where the airline determines a specific amount of seats to protect/reserve for a specific class or set of classes, in order to consolidate revenue from these (often more profitable) classes. The economic importance is exemplified by Delta Airlines' estimate that selling only one seat per flight at full rather than at discount rate adds over $50 million to its annual revenue[1].

Although the implementation of such control mechanisms have enormously increased profits for airline carriers, the complexity of developing and optimizing capacity allocation models can grow rapidly when multiple sources of uncertainties are involved. Demand fluctuations, for example, can bring variabilities to the reservation system. With the example of protection levels, if tickets are sold at a higher price class, the airline then increases their revenue, but if too many seats are reserved for the higher price class but its corresponding demand was insufficient, no revenue will be generated and the airline loses its opportunity to at least obtain some revenue from the discounted fare class. Additionally, variability in customer behavior such as cancellations and no-show can negatively impact flight revenue as the airline loses potential revenue to fly under capacity.

A common practice to tackle the above-mentioned uncertainties is overbooking, which involves selling at a virtual capacity above the airplane’s physical capacity to protect against potential revenue losses. However, since cancellations and no-shows are mostly random, the airline must carefully craft its ticket allocation plan and overbooking limit to achieve an optimal balance between maximizing single-flight revenue and minimizing denied boardings. In such cases where demand and customer behavior uncertainties complicate the booking system, simulation serves as a handy tool to model the possible scenarios accommodating realistic assumptions for demand distribution, cancellation probability and overbooking limits.

This study is adapted from Robert A. Shumsky’s case series, BlueSky Airlines - Single Leg Revenue Management [2], which focuses on the challenge of optimizing ticket protection level in face of uncertain demand. We will develop a base simulation model to determine the optimal booking limits and protection level that maximizes expected profit from a single-leg flight with two distinct customer segments (discount & full fare classes). The simulation model is then extended to versions that incorporate realistic customer behaviors such as buy-up, no-show and buy down, in order to further examine real-world impact of such complications on the optimal protection level and expected profit.

# Model Description

BlueSky airlines is currently facing a challenge in the setting of capacity limits for fare classes in order to achieve maximal profit. In this scenario, customers have the choice between buying a low-fare or full-fare ticket. BlueSky is able to profit more from full-fare tickets than low-fare tickets, but there is insufficient demand for full-fare tickets to completely fill up the aircraft.

The main parameter to be optimized is defined as Protection Level, which is the number of seats held in reserve for full-fare tickets. According to the original case description, demand for tickets is assumed to be normally distributed. The optimal protection level can be found by testing a range of scenarios through Monte Carlo Simulation. The random seed is kept constant to ensure the reproducibility of results.

Library Import

In [6]:
import pandas as pd
import numpy as np
import datetime
import matplotlib.pyplot as plt
import math
import scipy.stats as stats
import seaborn as sns
import plotly.graph_objects as go

Model Assumptions

  • Plane capacity = 146
  • Low-fare (lf) price = \$114, full-fare (ff) price = \\$174
  • Demand for full-fare tickets~ N(92,30)
  • Demand for low-fare tickets~ N(80,25)
In [7]:
#Set parameters
ff_mean = 92
ff_sd = 30
lf_mean = 80
lf_sd = 25
plane_cap = 146
ff_p = 174
lf_p = 114

Monte Carlo Simulation

In [8]:
#Set seed, search limits and initializations
np.random.seed(777)
protect_level = range(65,146)
mean_profit_record = []
lo95 = []
hi95 = []
In [9]:
#Simulation
for protect_index in protect_level:
    profit_record = []
    for i in range(0,1000):
        ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
        lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
        ff_sale = min(ff_demand,protect_index)
        lf_sale = min(lf_demand,plane_cap-protect_index)
        profit = ff_sale*ff_p + lf_sale*lf_p
        profit_record.append(profit)
    
    mean_profit = sum(profit_record)/len(profit_record)
    mean_profit_record.append(mean_profit)
     
    se = np.std(profit_record, ddof=1)/math.sqrt(1000)
    t_critical = stats.t.ppf(q = 0.975, df = 999)
    lo95.append(mean_profit - t_critical*se)
    hi95.append(mean_profit + t_critical*se)

results_bm=pd.DataFrame({"protection level":protect_level,"mean profit":mean_profit_record,
                         "lower 95%":lo95,"upper 95%":hi95})

#Sort by mean profit and return top 5 protect levels
results_bm.sort_values(by="mean profit", ascending=False).head(5)
Out[9]:
protection level mean profit lower 95% upper 95%
27 92 19975.986 19787.483836 20164.488164
23 88 19959.204 19784.537821 20133.870179
21 86 19935.564 19763.440404 20107.687596
30 95 19928.814 19732.742957 20124.885043
20 85 19912.752 19750.011505 20075.492495
In [10]:
#Results visualization
plt.figure()
plt.plot(results_bm["protection level"],results_bm["mean profit"], color="red")
plt.plot(results_bm["protection level"],results_bm["lower 95%"], '--',color = "blue",linewidth=1)
plt.plot(results_bm["protection level"],results_bm["upper 95%"], '--',color = "blue",linewidth=1)
plt.title('Simulation Results with 1000 Replications')
plt.xlabel("Protection Level")
plt.ylabel("Mean Profit($)")
plt.grid(True)
# Analysis of “What if” Scenarios

Scenario 1 : Buy-Up Behavior

This first scenario builds on top of the base model, incorporating a Buy-Up Behavior. In a more realistic scenario, customer preferences towards product specifications are rarely completely inflexible. Most customers are inclined to reconsider alternatives in reaction to new information, or if their first choice is no longer available. The Buy-Up behavior aims to incorporate this interaction into our model.

If a customer is unable to buy a low-fare ticket due to demand exceeding availability, there is now a chance for this customer to ‘Buy-Up’ to a full-fare ticket. This chance is modeled through the use of a binomial distribution, as oulined in the original case. A new simulation is run to find the profit maximizing protection level when this behavior is incorporated. Customer demand, ticket prices, plane capacity and random seed are kept constant and not changed.

Model Assumptions

  • Plane capacity = 146
  • Low-fare price = \$114, full-fare price = \\$174
  • Demand for full-fare tickets~ N(92,30)
  • Demand for low-fare tickets~ N(80,25)
    *When the low-fare tickets are unvailable, there is a 30% chance low-fare customers buy up~B(0.3, N)*
In [11]:
#Set parameters
ff_mean = 92
ff_sd = 30
lf_mean = 80
lf_sd = 25
buyup_prob = 0.3
plane_cap = 146
ff_p = 174
lf_p = 114

Monte Carlo Simulation

In [12]:
#Set seed, search limits and initializations
np.random.seed(777)
protect_level = range(65,146)
mean_profit_record = []
lo95 = []
hi95 = []
In [13]:
#Simulation
for protect_index in protect_level:
    profit_record = []
    for i in range(0,1000):
        ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
        lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
        lf_sale = min(lf_demand,plane_cap-protect_index)
        
        #Buy-up behavior adjustment
        lf_sdiff = lf_demand-(plane_cap-protect_index)
        if lf_sdiff > 0 : #Check if low-fare demand is greater than plane capacity
            lf_buyup = np.random.binomial(lf_sdiff,buyup_prob) #binomial distribution
            ff_demand += lf_buyup       
        ff_sale = min(ff_demand,protect_index)
        profit = ff_sale*ff_p + lf_sale*lf_p
        profit_record.append(profit)
    
    mean_profit = sum(profit_record)/len(profit_record)
    mean_profit_record.append(mean_profit)
     
    se = np.std(profit_record, ddof=1)/math.sqrt(1000)
    t_critical = stats.t.ppf(q = 0.975, df = 999)
    lo95.append(mean_profit - t_critical*se)
    hi95.append(mean_profit + t_critical*se)

results_s1=pd.DataFrame({"protect level":protect_level,"mean profit":mean_profit_record,"lower 95%":lo95,"upper 95%":hi95})

#Sort by mean profit and return top 10 protect levels
results_s1.sort_values(by="mean profit", ascending=False).head(10)
Out[13]:
protect level mean profit lower 95% upper 95%
41 106 20753.874 20558.535118 20949.212882
45 110 20753.628 20551.602164 20955.653836
42 107 20729.742 20529.455435 20930.028565
40 105 20685.366 20487.996144 20882.735856
54 119 20683.548 20445.770217 20921.325783
48 113 20674.236 20462.249496 20886.222504
44 109 20664.672 20451.631401 20877.712599
37 102 20659.368 20461.464711 20857.271289
35 100 20658.732 20467.945183 20849.518817
29 94 20641.998 20473.269112 20810.726888

Results Visualization

In [14]:
plt.figure()
plt.plot(results_s1["protect level"],results_s1["mean profit"], color="red")
plt.plot(results_s1["protect level"],results_s1["lower 95%"], '--',color = "blue",linewidth=1)
plt.plot(results_s1["protect level"],results_s1["upper 95%"], '--',color = "blue",linewidth=1)
plt.title('Simulation Results with 1000 Replications')
plt.xlabel("Protection Level")
plt.ylabel("Mean Profit($)")
plt.grid(True)

Observations

The optimal protection level went up from 92 to 106 after adding the buy-up behavior. It makes sense because the buy-up behavior potentially pushed up the demand for full-fare tickets, which means that we need a higher protection level to reserve the the seats for more passengers willing to pay full fare. The mean profit for the optimal decisions also went up as more passengers are willing to pay full-fare.

Scenario 2 : Buy-Up + No-Shows

The second scenario builds on top of scenario 1, further incorporating a No-Show probability. Rarely do all customers who have purchased a ticket show up on the day of travel. This could be due to emergencies, schedule changes or a host of other possibilities. However, every seat flown without a passenger is a lost opportunity for airlines to increase revenue. Therefore, there is a need for the model to capture this behavior in order to produce an accurate profit maximizing protection level.

In this scenario, we assume that low-fare customers have a 100% chance of showing up because low-fare tickets are non-refundable. There is a chance for full-fare customers to not show up, and would receive a full refund for their ticket in that case. If the model decides to overbook the flight, there is a chance that the customers who end up showing up would exceed plane capacity. In that case, customers who are unable to board due to overbooking would receive monetary compensation, which is taken into consideration in profit calculation as a loss for that flight. The degree of overbooking is represented by the Virtual Capacity, which is the combined number of tickets sold in the first place. The overbooked amount can be calculated by subtracting the actual plane capacity from virtual capacity. Again, parameters from previous scenarios are kept constant and remain unchanged.

Model Assumptions

  • Plane capacity = 146
  • Low-fare = \$114, full-fare = \\$174
  • Demand for full-fare ~ N(92,30)
  • Demand for low-fare ~ N(80,25)
  • When the low-fare tickets are unvailable, there is a 30% chance low-fare customers buy up~B(0.3, N)
    show-up probability for full-fare customers = 92%
    show-up probability for low-fare customers = 100%
    penalty cost per denied boarding = $180*</u>
In [15]:
#Set parameters
ff_mean = 92
ff_sd = 30
lf_mean = 80
lf_sd = 25
buyup_prob = 0.3
ffshowup_prob = 0.92
plane_cap = 146
ff_p = 174
lf_p = 114
penalty_unit = 180

Monte Carlo Simulation

In [16]:
#Set seed, search limits and initializations
np.random.seed(777)
capacity_level = range(146,246)
capacity_level_record = []
overbook_record = []
protect_level_record = []
mean_profit_record = []
lo95 = []
hi95 = []
avgdb_record=[]
In [17]:
#Simulation
for capacity_index in capacity_level:
    for protect_index in range(65,capacity_index+1):
        profit_record = []
        db_record = []
        for i in range(0,1000):
            profit = 0
            db = 0
            ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
            lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
            lf_sale = min(lf_demand,capacity_index-protect_index)
            
            #Buy-up behavior adjustment
            lf_sdiff = lf_demand-(capacity_index-protect_index)
            if lf_sdiff > 0 :
                lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
                ff_demand += lf_buyup       
            ff_sale = min(ff_demand,protect_index)
            
            #Overbooking penalty adjustment
            ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
            if ff_showup + lf_sale > plane_cap:
                db=-(plane_cap - ff_showup - lf_sale)
                penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
                profit += penalty
            profit += ff_showup*ff_p + lf_sale*lf_p #full-fare tickets are refundable
            profit_record.append(profit)
            db_record.append(db)
            
        capacity_level_record.append(capacity_index)
        overbook_record.append(capacity_index - plane_cap)
        protect_level_record.append(protect_index)
    
        mean_profit = sum(profit_record)/len(profit_record)
        mean_profit_record.append(mean_profit)
        avg_db = sum(db_record)/len(db_record)
        avgdb_record.append(avg_db)
     
        se = np.std(profit_record, ddof=1)/math.sqrt(1000)
        t_critical = stats.t.ppf(q = 0.975, df = 999)
        lo95.append(mean_profit - t_critical*se)
        hi95.append(mean_profit + t_critical*se)

results_s2 = pd.DataFrame({"virtual capacity":capacity_level_record, "overbooking limit": overbook_record, 
                           "protect level":protect_level_record, "mean profit":mean_profit_record, "lower 95%":lo95,
                           "upper 95%":hi95,"avg denied boarding":avgdb_record})

#Sort by mean profit and return top 10 protect levels
results_s2.sort_values(by="mean profit", ascending=False).head(10)
Out[17]:
virtual capacity overbooking limit protect level mean profit lower 95% upper 95% avg denied boarding
1093 158 12 108 20321.712 20144.401964 20499.022036 1.597
7362 210 64 163 20310.180 20120.494099 20499.865901 9.458
4872 192 46 130 20298.678 20146.573992 20450.782008 11.752
9185 222 76 168 20297.544 20129.386923 20465.701077 12.256
8566 218 72 171 20296.218 20110.932899 20481.503101 9.014
12031 239 93 192 20279.790 20091.769141 20467.810859 9.737
11861 238 92 196 20272.854 20077.503568 20468.204432 7.890
1676 164 18 112 20266.146 20089.864165 20442.427835 3.696
997 157 11 105 20261.616 20080.264353 20442.967647 1.321
2196 169 23 122 20258.088 20063.145194 20453.030806 4.608

Observations

The optimal protection level slightly went up from 106 to 108 after adding the over-booking. This is caused by the 8% possibility of the full fare passengers not showing up. A high virtual capacity (thus more overbooking) and a high protection level seem to be mostly favoured in the top 10 optimal combinations, meaning that we sell a large number of full-fare tickets to ensure enough full-fare passengers to show up to obtain a higher profit. However, this policy did not reverse the impact of no-shows, and the mean profit for the optimal decision still went down.

Scenario 3 : Buy-Up + No-Shows + Buy-Down (Complete Model)

The third and final scenario builds on top of scenario 2, adding a Buy-Down behavior as well. In scenario 1, we mentioned that customers may sometimes go for a (more expensive) alternative when their first choice is no longer available; the opposite of this is true as well. Therefore, there is now a chance for full-fare customers to ‘buy-down’ to low-fare tickets. Similar to the ‘buy-up’ behavior, the probability of buying down is also modeled through the use of a binomial distribution, according to the original case description.

Since low-fare tickets are supposed to be representative of a cheaper inflexible ticket class and full-fare tickets are representative of a more expensive ticket class with a high degree of flexibility, it makes sense to have low-fare tickets sell out first before full-fare tickets. Therefore, we assume that the ‘buy-down’ behavior happens before the ‘buy-up’ behavior, as low-fare customers would only pay more to buy a full-fare ticket if there are no more low-fare tickets. Parameters from previous scenarios are also kept constant and remain unchanged.

Assumptions

  • Plane capacity = 146
  • Low-fare price = \$114, full-fare price = \\$174
  • Demand for full-fare tickets~ N(92,30)
  • Demand for low-fare tickets~ N(80,25)
  • When the low-fare tickets are unvailable, there is a 30% chance low-fare customers buy up~B(0.3, N)
    *60% chance full-fare customers buy down ~ B(0.6, N)*
    show-up probability for full-fare customers = 92%
    show-up probability for low-fare customers = 100%
    penalty cost for per overbooking = $180
In [18]:
#Set parameters
ff_mean = 92
ff_sd = 30
lf_mean = 80
lf_sd = 25
buyup_prob = 0.3
buydown_prob = 0.6
ffshowup_prob = 0.92
plane_cap = 146
ff_p = 174
lf_p = 114
penalty_unit = 180

Monte Carlo Simulation

In [19]:
#Set seed, search limits and initializations
np.random.seed(777)
capacity_level = range(146,246)
capacity_level_record = []
overbook_record = []
protect_level_record = []
mean_profit_record = []
lo95 = []
hi95 = []
avgdb_record=[]
In [20]:
#Simulation
for capacity_index in capacity_level:
    for protect_index in range(65,capacity_index+1):
        profit_record = []
        db_record = []
        for i in range(0,1000):
            profit = 0
            db=0
            ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
            lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
            
            #Buy-down behavior adjustment
            ff_buydown = np.random.binomial(ff_demand,buydown_prob)
            ff_demand -= ff_buydown 
            lf_demand += ff_buydown
            
            #Buy-up behavior adjustment
            lf_sale = min(lf_demand,capacity_index-protect_index)
            lf_sdiff = lf_demand-(capacity_index-protect_index)
            if lf_sdiff > 0 :
                lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
                ff_demand += lf_buyup       
            ff_sale = min(ff_demand,protect_index)
            
            #Overbooking penalty adjustment
            ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
            if ff_showup + lf_sale > plane_cap:
                db=-(plane_cap - ff_showup - lf_sale)
                penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
                profit += penalty
            profit += ff_showup*ff_p + lf_sale*lf_p
            profit_record.append(profit)
            db_record.append(db)
            
        capacity_level_record.append(capacity_index)
        overbook_record.append(capacity_index - plane_cap)
        protect_level_record.append(protect_index)
    
        mean_profit = sum(profit_record)/len(profit_record)
        mean_profit_record.append(mean_profit)
        avg_db = sum(db_record)/len(db_record)
        avgdb_record.append(avg_db)
     
        se = np.std(profit_record, ddof=1)/math.sqrt(1000)
        t_critical = stats.t.ppf(q = 0.975, df = 999)
        lo95.append(mean_profit - t_critical*se)
        hi95.append(mean_profit + t_critical*se)

results_s3 = pd.DataFrame({"virtual capacity":capacity_level_record,"overbooking limit": overbook_record,
                           "protect level":protect_level_record,"mean profit":mean_profit_record,
                           "lower 95%":lo95,"upper 95%":hi95,"avg denied boarding":avgdb_record})

#Sort by mean profit and return top 10 protect levels
results_s3.sort_values(by="mean profit", ascending=False).head(10)
Out[20]:
virtual capacity overbooking limit protect level mean profit lower 95% upper 95% avg denied boarding
10939 233 87 129 17950.110 17831.333301 18068.886699 7.476
5488 197 51 96 17938.404 17812.215659 18064.592341 6.340
3598 182 36 81 17932.128 17809.007918 18055.248082 6.008
1631 164 18 67 17923.860 17789.743361 18057.976639 3.472
8211 216 70 121 17923.458 17785.453120 18061.462880 4.858
11285 235 89 136 17912.802 17780.953496 18044.650504 5.574
11976 239 93 137 17910.072 17778.670613 18041.473387 6.835
2571 173 27 71 17909.820 17789.112165 18030.527835 5.590
7906 214 68 117 17907.984 17768.517544 18047.450456 5.576
3723 183 37 88 17906.928 17771.538828 18042.317172 4.766

Observations

The profit-maximizing protection level went up significantly from 108 to 129 after adding buy-down behavior. The buy-down behavior is essentially a decrease on the demand of full-fare tickets, and it makes sense to expect fewer full-fare passengers. The rise is added to prevent buy-down behavior. The effectiveness of this policy is low and we will lose a large amount of profits with thw buy-down behavior. This also implies how competitive the low fare airline market is.

Verification on the Scenario 3 (Most Complete Model)

The simulation model can be verified by eliminating all sources of variation so that it is reasonably straightforward to calculate the results. A final model with these parameters would be essentially equivalent to the base-level model due. If we manage to get the same results as the first model, we can be certain that the logic coded into the full model is correct.

To achieve this result, we first changed the standard deviation of demand to 0 so that passenger demand for tickets is always constant. The next step is to set the probability of ‘buy-up’ and ‘buy-down’ to close to 0 to mostly eliminate buy-up/down behavior. The last step is to set the probability of customers showing up close to 1. Probabilities are set close to 0 and 1 instead of the numbers itself in order to retain the feature for verification purposes.

Assumptions

We assume there is no variation with the demand, with

  • demand for full-fare ~ N(92,0)
  • demand for low-fare ~ N(80,0).

Chance of buy-up and buy-down behavior is close to 0.
Chance of full-fare customers showing up is close to 1.

In [21]:
#Set parameters for verification
ff_mean = 92
ff_sd = 0
lf_mean = 80
lf_sd = 0
buyup_prob = 0.000001
buydown_prob = 0.000001
ffshowup_prob = 0.99999
plane_cap = 146
ff_p = 174
lf_p = 114
penalty_unit = 180

Monte Carlo Simulation

In [22]:
#Set seed, search limits and initializations
np.random.seed(777)
capacity_level = range(146,246)
capacity_level_record = []
overbook_record = []
protect_level_record = []
mean_profit_record = []
lo95 = []
hi95 = []
avgdb_record=[]
In [23]:
#Simulation
for capacity_index in capacity_level:
    for protect_index in range(65,capacity_index+1):
        profit_record = []
        db_record = []
        for i in range(0,1000):
            profit = 0
            db=0
            ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
            lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
            
            #Buy-down behavior adjustment
            ff_buydown = np.random.binomial(ff_demand,buydown_prob)
            ff_demand -= ff_buydown 
            lf_demand += ff_buydown
            
            #Buy-up behavior adjustment
            lf_sale = min(lf_demand,capacity_index-protect_index)
            lf_sdiff = lf_demand-(capacity_index-protect_index)
            if lf_sdiff > 0 :
                lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
                ff_demand += lf_buyup       
            ff_sale = min(ff_demand,protect_index)
            
            #Overbooking penalty adjustment
            ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
            if ff_showup + lf_sale > plane_cap:
                db=-(plane_cap - ff_showup - lf_sale)
                penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
                profit += penalty
            profit += ff_showup*ff_p + lf_sale*lf_p
            profit_record.append(profit)
            db_record.append(db)
            
        capacity_level_record.append(capacity_index)
        overbook_record.append(capacity_index - plane_cap)
        protect_level_record.append(protect_index)
    
        mean_profit = sum(profit_record)/len(profit_record)
        mean_profit_record.append(mean_profit)
        avg_db = sum(db_record)/len(db_record)
        avgdb_record.append(avg_db)
     
        se = np.std(profit_record, ddof=1)/math.sqrt(1000)
        t_critical = stats.t.ppf(q = 0.975, df = 999)
        lo95.append(mean_profit - t_critical*se)
        hi95.append(mean_profit + t_critical*se)

results_v = pd.DataFrame({"virtual capacity":capacity_level_record,"overbooking limit": overbook_record, 
                          "protect level":protect_level_record, "mean profit":mean_profit_record,
                          "lower 95%":lo95,"upper 95%":hi95, "avg denied boarding":avgdb_record})

#Sort by mean profit and return top 10 protect levels
results_v.sort_values(by="mean profit", ascending=False).head(10)
Out[23]:
virtual capacity overbooking limit protect level mean profit lower 95% upper 95% avg denied boarding
365 150 4 96 22164.0 22164.0 22164.0 0.0
12377 241 95 187 22164.0 22164.0 22164.0 0.0
902 156 10 102 22164.0 22164.0 22164.0 0.0
10154 228 82 174 22164.0 22164.0 22164.0 0.0
7502 211 65 157 22164.0 22164.0 22164.0 0.0
7650 212 66 158 22164.0 22164.0 22164.0 0.0
11502 236 90 182 22164.0 22164.0 22164.0 0.0
1089 158 12 104 22164.0 22164.0 22164.0 0.0
2295 170 24 116 22164.0 22164.0 22164.0 0.0
13095 245 99 191 22164.0 22164.0 22164.0 0.0

Observations

When the demands are fixed without variation, we can see that the profit is constant and maximized whenever the full-fare sale is at 92 and low-fare sale at 54 (146-92). There is no denied boarding. It verifies the logic of our models.

Decisions Exploration

In the full model, the final profit figure is heavily reliant on two decision variables: Protection Level and Virtual Capacity. As the two variables can only be integers, we can visualize the effect on profit by looking at an exhaustive set of combinations in a pre-defined range. Note that this visualization is not an absolute representation of the marginal change on profit per change in each decision variable, as the results are obtained from simulation. Changing the random seed would result in different profit figures, so the graph below can only be used as a guideline. However, for the purposes of this case study, enough repetitions have been done so that the results are representative of an overall outcome. Therefore, we are still able to gain valuable insight into the effect of the decision variables on profit.

In [24]:
results_map=results_s3[["virtual capacity","protect level","mean profit"]]
map_df = results_map.pivot(index='protect level', columns="virtual capacity", values="mean profit")
plt.figure(figsize = (16,10))
ax = sns.heatmap(map_df, linewidth=0)
ax.set_title('Decisions Exploration')
plt.xlabel = "Virtual Capacity"
plt.ylabel = "Protection Level"
#plt.grid(True)
plt.show()

Observations

From the plot above, we can see that the highest likelihood of getting a high profit is clustered in an even band from top left corner to the bottom right corner (where the color is lightest). The likelihood of getting a higher profit decreases evenly further away from the band.

Parameter Sensitivity Analysis

Sensitivity analysis can be done on the final model by varying each individual parameter while holding others constant to observe the effect on profit. We also assume that an airline in real life would be sensitive towards the amount of people who were denied boarding as it is extremely costly and diminishes brand reputation. Therefore, the effect of parameter changes on the average number of customers who were denied boarding are investigated as well. To hold decision variables constant, we chose to perform sensitivity analysis on the top-5 combinations of Protection Level and Virtual Capacity in terms of generating the maximal profit.

The threshold level was set to +/- 10% and 20% to simulate real life conditions. Using both 10% & 20% would also have the added benefit of revealing any linear effects of parameters on profit and number of passengers denied boarding. One caveat to the parameter selection is for the ‘show-up’ probability, or 1 – the no-show probability. Since the original probability of a full-fare customer showing up is 92%, we cannot increase it by 10% as it will exceed 100%. Therefore, this parameter is capped at 100% for the purposes of sensitivity analysis. Again, it is worth mentioning that these results are meant to be used as a guideline and not as absolute marginal effects, as there will always be fluctuations in the result for different random seeds.

In [25]:
#Set unchanged parameters
plane_cap = 146
ff_p = 174
lf_p = 114
In [26]:
#Sensitivity analysis preparation
ff_mean = 92
lf_mean = 80
ff_sd = 30
lf_sd = 25
buyup_prob = 0.3
buydown_prob = 0.6
ffshowup_prob = 0.92
penalty_unit = 180

sensi_lst = [92, 80, 30, 25, 0.3, 0.6, 0.92, 180]

#Assign changed values to parameters list
def assign(sensi_lst):
    ff_mean = sensi_lst[0]
    lf_mean = sensi_lst[1]
    ff_sd = sensi_lst[2]
    lf_sd = sensi_lst[3]
    buyup_prob = sensi_lst[4]
    buydown_prob = sensi_lst[5]
    ffshowup_prob = sensi_lst[6]
    penalty_unit = sensi_lst[7]

#Reset global parameters list
def reset():
    global sensi_lst
    sensi_lst = [92, 30, 80, 25, 0.3, 0.6, 0.92, 180]
In [27]:
#Set seed, search limits and initializations
np.random.seed(777)
capacity_level = range(146,246)
results_record = pd.DataFrame() 
In [28]:
#Top5 pairs from Scenario 3
optimal_virtual_capacity = [233,197,182,164,216]
optimal_protect_level = [129,96,81,67,121]
In [29]:
#-10%
for q in range(0,5):
    capacity_index = optimal_virtual_capacity[q]
    protect_index = optimal_protect_level[q]
    
    for lst_index in range(0,8):
        sensi_lst[lst_index] = sensi_lst[lst_index]*0.9
        assign(sensi_lst)
    
        profit_record = []
        db_record=[]
    
        for i in range(0,1000):
            profit = 0
            db = 0
            ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
            lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
            
            #Buy-down behavior adjustment
            ff_buydown = np.random.binomial(ff_demand,buydown_prob)
            ff_demand -= ff_buydown 
            lf_demand += ff_buydown
            
            #Buy-up behavior adjustment
            lf_sale = min(lf_demand,capacity_index-protect_index)
            lf_sdiff = lf_demand-(capacity_index-protect_index)
            if lf_sdiff > 0 :
                lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
                ff_demand += lf_buyup       
            ff_sale = min(ff_demand,protect_index)
            
            #Overbooking penalty adjustment
            ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
            if ff_showup + lf_sale > plane_cap:
                db=-(plane_cap - ff_showup - lf_sale)
                penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
                profit += penalty
            profit += ff_showup*ff_p + lf_sale*lf_p
            
            profit_record.append(profit)
            db_record.append(db)
    
        mean_profit = sum(profit_record)/len(profit_record)
        avg_db = sum(db_record)/len(db_record)

        results = pd.DataFrame({"virtual capacity":capacity_index,"protect level":protect_index,
                                "mean profit":mean_profit, "avg denied boarding":avg_db},index = [q])
        results_record = pd.concat([results_record,results],axis=0)
        reset()
In [30]:
#+10%
for q in range(0,5):
    capacity_index = optimal_virtual_capacity[q]
    protect_index = optimal_protect_level[q]
    
    for lst_index in range(0,8):
        #+10% change is not applicable to ffshowup_prob, as its original value is 0.92. 
        #So we set ffshowup_prob = 1.0 when applying +10% change.
        if lst_index != 6: 
            sensi_lst[lst_index] = sensi_lst[lst_index]*1.1
        else:
            sensi_lst[lst_index] = 1.0
        assign(sensi_lst)
    
        profit_record = []
        db_record=[]
    
        for i in range(0,1000):
            profit = 0
            db = 0
            ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
            lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
            
            #Buy-down behavior adjustment
            ff_buydown = np.random.binomial(ff_demand,buydown_prob)
            ff_demand -= ff_buydown 
            lf_demand += ff_buydown
            
            #Buy-up behavior adjustment
            lf_sale = min(lf_demand,capacity_index-protect_index)
            lf_sdiff = lf_demand-(capacity_index-protect_index)
            if lf_sdiff > 0 :
                lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
                ff_demand += lf_buyup       
            ff_sale = min(ff_demand,protect_index)
            
            #Overbooking penalty adjustment
            ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
            if ff_showup + lf_sale > plane_cap:
                db=-(plane_cap - ff_showup - lf_sale)
                penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
                profit += penalty
            profit += ff_showup*ff_p + lf_sale*lf_p
            
            profit_record.append(profit)
            db_record.append(db)
    
        mean_profit = sum(profit_record)/len(profit_record)
        avg_db = sum(db_record)/len(db_record)

        results = pd.DataFrame({"virtual capacity":capacity_index,"protect level":protect_index,
                                "mean profit":mean_profit, "avg denied boarding":avg_db},index = [q])
        results_record = pd.concat([results_record,results],axis=0)
        reset()
In [31]:
#-20%
for q in range(0,5):
    capacity_index = optimal_virtual_capacity[q]
    protect_index = optimal_protect_level[q]
    
    for lst_index in range(0,8):
        sensi_lst[lst_index] = sensi_lst[lst_index]*0.8
        assign(sensi_lst)
    
        profit_record = []
        db_record=[]
    
        for i in range(0,1000):
            profit = 0
            db = 0
            ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
            lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
            
            #Buy-down behavior adjustment
            ff_buydown = np.random.binomial(ff_demand,buydown_prob)
            ff_demand -= ff_buydown 
            lf_demand += ff_buydown
            
            #Buy-up behavior adjustment
            lf_sale = min(lf_demand,capacity_index-protect_index)
            lf_sdiff = lf_demand-(capacity_index-protect_index)
            if lf_sdiff > 0 :
                lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
                ff_demand += lf_buyup       
            ff_sale = min(ff_demand,protect_index)
            
            #Overbooking penalty adjustment
            ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
            if ff_showup + lf_sale > plane_cap:
                db=-(plane_cap - ff_showup - lf_sale)
                penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
                profit += penalty
            profit += ff_showup*ff_p + lf_sale*lf_p
            
            profit_record.append(profit)
            db_record.append(db)
    
        mean_profit = sum(profit_record)/len(profit_record)
        avg_db = sum(db_record)/len(db_record)

        results = pd.DataFrame({"virtual capacity":capacity_index,"protect level":protect_index,
                                "mean profit":mean_profit, "avg denied boarding":avg_db},index = [q])
        results_record = pd.concat([results_record,results],axis=0)
        reset()
In [32]:
#+20%
for q in range(0,5):
    capacity_index = optimal_virtual_capacity[q]
    protect_index = optimal_protect_level[q]
    
    for lst_index in range(0,8):
        #+20% change is not applicable to ffshowup_prob, as its original value is 0.92. 
        #So we set ffshowup_prob = 1.0 when applying +20% change.
        if lst_index != 6:
            sensi_lst[lst_index] = sensi_lst[lst_index]*1.2
        else:
            sensi_lst[lst_index] = 1.0
        assign(sensi_lst)
    
        profit_record = []
        db_record=[]
    
        for i in range(0,1000):
            profit = 0
            db = 0
            ff_demand = max(round(np.random.normal(ff_mean, ff_sd)),0)
            lf_demand = max(round(np.random.normal(lf_mean, lf_sd)),0)
            
            #Buy-down behavior adjustment
            ff_buydown = np.random.binomial(ff_demand,buydown_prob)
            ff_demand -= ff_buydown 
            lf_demand += ff_buydown
            
            #Buy-up behavior adjustment
            lf_sale = min(lf_demand,capacity_index-protect_index)
            lf_sdiff = lf_demand-(capacity_index-protect_index)
            if lf_sdiff > 0 :
                lf_buyup = np.random.binomial(lf_sdiff,buyup_prob)
                ff_demand += lf_buyup       
            ff_sale = min(ff_demand,protect_index)
            
            #Overbooking penalty adjustment
            ff_showup = np.random.binomial(ff_sale,ffshowup_prob)
            if ff_showup + lf_sale > plane_cap:
                db=-(plane_cap - ff_showup - lf_sale)
                penalty = penalty_unit*(plane_cap - ff_showup - lf_sale)
                profit += penalty
            profit += ff_showup*ff_p + lf_sale*lf_p
            
            profit_record.append(profit)
            db_record.append(db)
    
        mean_profit = sum(profit_record)/len(profit_record)
        avg_db = sum(db_record)/len(db_record)

        results = pd.DataFrame({"virtual capacity":capacity_index,"protect level":protect_index,
                                "mean profit":mean_profit, "avg denied boarding":avg_db},index = [q])
        results_record = pd.concat([results_record,results],axis=0)
        reset()
In [33]:
#Adding Change column to indicate which parameter is changed each round
change1 = ["ff_mean-10%","lf_mean-10%","ff_sd-10%","lf_sd-10%","buyup_prob-10%","buydown_prob-10%",
           "ffshowup_prob-10%","penalty_unit-10%"]
change2 = ["ff_mean+10%","lf_mean+10%","ff_sd+10%","lf_sd+10%","buyup_prob+10%","buydown_prob+10%",
           "ffshowup_prob=1.0","penalty_unit+10%"]
change3 = ["ff_mean-20%","lf_mean-20%","ff_sd-20%","lf_sd-20%","buyup_prob-20%","buydown_prob-20%",
           "ffshowup_prob-20%","penalty_unit-20%"]
change4 = ["ff_mean+20%","lf_mean+20%","ff_sd+20%","lf_sd+20%","buyup_prob+20%","buydown_prob+20%",
           "ffshowup_prob=1.0","penalty_unit+20%"]
results_record["Change"] = change1*5+change2*5+change3*5+change4*5
results_record = results_record[['Change','virtual capacity', 'protect level', 'mean profit', 'avg denied boarding' ]]
In [34]:
#Top5 results from Scenario 3
top5 = results_s3.sort_values(by="mean profit", ascending=False).head(5)
top5["mean profit %change"] = 100
top5["avg denied boarding %change"] = 100
In [35]:
#Record original profit and average denied boarding for %change calculation
original_profit_lst = (list(top5["mean profit"][0:1])*8 + list(top5["mean profit"][1:2])*8 
                       + list(top5["mean profit"][2:3])*8 + list(top5["mean profit"][3:4])*8 
                       + list(top5["mean profit"][4:5])*8)*4
original_avgdb_lst = (list(top5["avg denied boarding"][0:1])*8 + list(top5["avg denied boarding"][1:2])*8 
                      + list(top5["avg denied boarding"][2:3])*8 + list(top5["avg denied boarding"][3:4])*8 
                      + list(top5["avg denied boarding"][4:5])*8)*4
In [36]:
#Calculate %change for each round of change
results_record["mean profit %change"] = (results_record["mean profit"]/np.array(original_profit_lst) - 1)*100
results_record["avg denied boarding %change"] = (results_record["avg denied boarding"]/np.array(original_avgdb_lst) - 1)*100
In [37]:
results_record
Out[37]:
Change virtual capacity protect level mean profit avg denied boarding mean profit %change avg denied boarding %change
0 ff_mean-10% 233 129 17797.434 7.559 -0.850557 1.110219
0 lf_mean-10% 233 129 17768.526 7.482 -1.011604 0.080257
0 ff_sd-10% 233 129 17764.404 7.668 -1.034567 2.568218
0 lf_sd-10% 233 129 17687.778 7.363 -1.461451 -1.511503
0 buyup_prob-10% 233 129 17805.954 7.281 -0.803093 -2.608347
... ... ... ... ... ... ... ...
4 lf_sd+20% 216 121 17843.964 4.707 -0.443519 -3.108275
4 buyup_prob+20% 216 121 17798.154 4.686 -0.699106 -3.540552
4 buydown_prob+20% 216 121 17641.770 4.463 -1.571616 -8.130918
4 ffshowup_prob=1.0 216 121 17789.586 4.142 -0.746909 -14.738576
4 penalty_unit+20% 216 121 17765.580 4.495 -0.880846 -7.472211

160 rows × 7 columns


#### *Please note that all plots below are interactive, with levels being able to toggled on/off by clicking on the legend*

Absolute Value Comparison of Mean Profit for Top 5 Pairs from Scenario 3

In [38]:
categories = ["ff_mean","lf_mean","ff_sd","lf_sd","buyup_prob","buydown_prob","ffshowup_prob","penalty_unit"]
change = ["-10%","+10%", "-20%", "+20%", "Original"]
colors = ["skyblue","lightsalmon", "steelblue" , "tomato", "lightblue"]
titles = ["Mean Profit: Virtual Capacity = 233, Protection Level = 129",
          "Mean Profit: Virtual Capacity = 197, Protection Level = 96",
          "Mean Profit: Virtual Capacity = 182, Protection Level = 81",
          "Mean Profit: Virtual Capacity = 164, Protection Level = 67",
         "Mean Profit: Virtual Capacity = 216, Protection Level = 121"]

for combo in range(0,5):    
    fig = go.Figure()
    j = -1
    fig.add_trace(go.Scatterpolar(
        r= list(top5['mean profit'][combo:combo+1])*8,
        theta=categories,
        fill='toself',
        name=change[4],
        line_color = colors[4]
        ))
    
    for i in range(8+combo*8,161,40):
        j += 1
        fig.add_trace(go.Scatterpolar(
        r= results_record['mean profit'][i-8:i],
        theta=categories,
        fill='toself',
        name=change[int((i-8*(combo+1))/40)],
        line_color = colors[j]
        ))
 
    fig.update_layout(
        title = titles[combo],
       polar=dict(
        radialaxis=dict(
         visible=True,
         range=[17500, 18000]
        )),
       showlegend=True
    )
    fig.show()
    

% Change Comparison of Mean Profit for Top 5 Pairs from Scenario 3

In [39]:
categories = ["ff_mean","lf_mean","ff_sd","lf_sd","buyup_prob","buydown_prob","ffshowup_prob","penalty_unit"]
change = ["-10%","+10%", "-20%", "+20%", "Original"]
colors = ["skyblue","lightsalmon", "steelblue" , "tomato", "lightblue"]
titles = ["Mean Profit %Change: Virtual Capacity = 233, Protection Level = 129",
          "Mean Profit %Change: Virtual Capacity = 197, Protection Level = 96",
          "Mean Profit %Change: Virtual Capacity = 182, Protection Level = 81",
          "Mean Profit %Change: Virtual Capacity = 164, Protection Level = 67",
         "Mean Profit %Change: Virtual Capacity = 216, Protection Level = 121"]

for combo in range(0,5):    
    fig = go.Figure()
    j = -1
    fig.add_trace(go.Scatterpolar(
        r= list(top5['mean profit %change'][combo:combo+1])*8,
        theta=categories,
        fill='toself',
        name=change[4],
        line_color = colors[4]
        ))
    
    for i in range(8+combo*8,161,40):
        j += 1
        fig.add_trace(go.Scatterpolar(
        r= results_record['mean profit %change'][i-8:i],
        theta=categories,
        fill='toself',
        name=change[int((i-8*(combo+1))/40)],
        line_color = colors[j]
        ))
 
    fig.update_layout(
        title = titles[combo],
       polar=dict(
        radialaxis=dict(
         visible=True,
         range=[-2, 0.5]
        )),
       showlegend=True
    )
    fig.show()
    

Observations

No matter the parameters change towards the favorable side or unfavorable side, the mean profit will almost decline (0.17%~2.06%) for all top 5 pairs of protection level and virtual capacity, except the third pair (Virtual Capacity = 182, Protection Level = 81), whose mean profit increase 0.08% when the low-fare standard deviation parameter decreases by 20%.

Absolute Value Comparison of Average # of Denied Boarding for Top 5 Pairs from Scenario 3

In [40]:
categories = ["ff_mean","lf_mean","ff_sd","lf_sd","buyup_prob","buydown_prob","ffshowup_prob","penalty_unit"]
change = ["-10%","+10%", "-20%", "+20%", "Original"]
colors = ["skyblue","lightsalmon", "steelblue" , "tomato", "lightblue"]
titles = ["Avgerage # of Denied Boarding: Virtual Capacity = 233, Protection Level = 129",
          "Avgerage # of Denied Boarding: Virtual Capacity = 197, Protection Level = 96",
          "Avgerage # of Denied Boarding: Virtual Capacity = 182, Protection Level = 81",
          "Avgerage # of Denied Boarding: Virtual Capacity = 164, Protection Level = 67",
         "Avgerage # of Denied Boarding: Virtual Capacity = 216, Protection Level = 121"]

for combo in range(0,5):    
    fig = go.Figure()
    j = -1
    fig.add_trace(go.Scatterpolar(
        r= list(top5['avg denied boarding'][combo:combo+1])*8,
        theta=categories,
        fill='toself',
        name=change[4],
        line_color = colors[4]
        ))
    
    for i in range(8+combo*8,161,40):
        j += 1
        fig.add_trace(go.Scatterpolar(
        r= results_record['avg denied boarding'][i-8:i],
        theta=categories,
        fill='toself',
        name=change[int((i-8*(combo+1))/40)],
        line_color = colors[j]
        ))
 
    fig.update_layout(
        title = titles[combo],
       polar=dict(
        radialaxis=dict(
         visible=True,
         range=[0, 8]
        )),
       showlegend=True
    )
    fig.show()
    
    

% Change Comparison of Average # of Denied Boarding for Top 5 Pairs from Scenario 3

In [41]:
categories = ["ff_mean","lf_mean","ff_sd","lf_sd","buyup_prob","buydown_prob","ffshowup_prob","penalty_unit"]
change = ["-10%","+10%", "-20%", "+20%", "Original"]
colors = ["skyblue","lightsalmon", "steelblue" , "tomato", "lightblue"]
titles = ["Average Denied Boarding %change: Virtual Capacity = 233, Protection Level = 129",
          "Average Denied Boarding %change: Virtual Capacity = 197, Protection Level = 96",
          "Average Denied Boarding %change: Virtual Capacity = 182, Protection Level = 81",
          "Average Denied Boarding %change: Virtual Capacity = 164, Protection Level = 67",
         "Average Denied Boarding %change: Virtual Capacity = 216, Protection Level = 121"]

for combo in range(0,5):    
    fig = go.Figure()
    j = -1
    fig.add_trace(go.Scatterpolar(
        r= list(top5['avg denied boarding %change'][combo:combo+1])*8,
        theta=categories,
        fill='toself',
        name=change[4],
        line_color = colors[4]
        ))
    
    for i in range(8+combo*8,161,40):
        j += 1
        fig.add_trace(go.Scatterpolar(
        r= results_record['avg denied boarding %change'][i-8:i],
        theta=categories,
        fill='toself',
        name=change[int((i-8*(combo+1))/40)],
        line_color = colors[j]
        ))
 
    fig.update_layout(
        title = titles[combo],
       polar=dict(
        radialaxis=dict(
         visible=True,
         range=[-20, 15]
        )),
       showlegend=True
    )
    fig.show()
    
    

Observations

The average number of denied boarding change in a more complex way, regarding different pair of virtual capacity and protection level. The percentage of change ranges from -17.76% to 10.59%.

# Discussion

After using multiple models to simulate profit under a wide range of scenarios, we found that there is a complex relationship between the decision variables Protection Level, Virtual Capacity, Overbooking Limit and profit.

For the base model where the only significant variation is passenger demand, the optimal protection level appears to be around the 85-95 range. Logically, BlueSky should be able to achieve maximal profit for a given demand when the protection level is set just high enough to capture all of the full-fare demand. Setting protection level too high would result in empty seats on a plane and drive down profit due to insignificant full-fare demand. Therefore, this range is a representation of the combined means of the demand distribution, as it is the most common occurrence.

In scenario 1, the buy-up behavior gives low-fare customers an opportunity to change into full-fare customers. Therefore, it makes sense to raise the protection level and reserve more capacity for full-fare tickets in order to capture this increase in demand. This resulted in a higher new optimal protection level, as well as increased mean profit compared to the base model. Thus, we can conclude that the buy-up behavior has a net positive effect on profit and should be encouraged.

The results become harder to interpret after scenario 1, as there are now multiple sources of variation impacting profit. Looking at the results from scenario 2, we can see that mean profit for the optimal decision variables stayed roughly the same when compared to scenario 1. This means that under the base parameters of show-up probability and denied boarding penalty (given in the case study), the positive effects of overbooking and negative effects of paying out a denied boarding penalty is able to balance out each other, resulting in a net zero effect. Therefore, as long as BlueSky is able to use this model to select an optimal combination of decision variables, they should still be able to earn the same amount of profit in scenario 2.

Yet in scenario 3, when we incorporate the ‘buy-down’ behavior, profits were reduced by a large amount (approx. 10-12% on average) even if the optimal combination of decision variables were chosen. The ‘buy-down’ behavior removed a large amount of full-fare demand, driving down profits gained from the ‘buy-up’ behavior. However, the average number of people who were denied boarding are now less varied. This increase in stability is likely due to the model now having to manage multiple sources of variation, narrowing down the range of possible combinations of decision variables.

The relationship between the decision variables becomes clearer when we perform exhaustive exploration of possible combinations. Looking at the decisions exploration graph, we see that the area of highest profit (lightest area) forms an approximately linear trend-line with a negative slope. The interpretation is that for the final model with complete features, virtual capacity should be increased as protection level increases. This means that BlueSky should increase the overbooking amount as they reserve more seats for full-fare in order to compensate for the extra penalty costs they would be paying. There is likely a "golden ratio" between protection level and virtual capacity for every possible optimal combination. However, since the results are obtained from simulation, there is still a lot of uncertainty and noise in the results which can visualized by the many different shades of color in the graph. This ratio would also change for different parameters, so the optimal solution would still be to undergo simulations.

The sensitivity analysis also provides insight on the estimated effect of parameter changes for the top profit maximizing decision variable combinations. Visualizing the effects of the top combinations is enough because BlueSky should always be wanting to maximize profit and be only concerned about the sensitivity of profit under this situation. We assume that the decision variables (Protection Level & Virtual Capacity) are limits that BlueSky must set and leave it unchanged in the short-term. Therefore, it would be useful to understand the effects of profit from fluctuations in model parameters (i.e. low-fare demand). Observing the results, we see that profit always decreases when parameters are changed, even in positive ways such as increased demand as the decision variables are not optimized anymore under this new environment. Furthermore, none of the relationships are linear in nature, and do not exhibit any sort of consistent pattern. However, we can still conclude that profit is not very sensitive to changes in a single model parameter as even a 20% drop in demand mean only resulted in profit decrease of < 2%. The number of people who are denied boarding is also fairly insensitive to parameter changes, with only a fluctuation of 1-2 people in most cases.

# Recommendations & Conclusion

Overall, BlueSky’s optimization challenge can be effectively handled through the use of Monte Carlo simulations. The above models are able to find the optimal profit maximizing decision variable in a range of scenarios if given the correct input variables. Even with the optimal decision variables, key outcome measures such as profit and amount of passengers denied boarding will still decrease as model parameters are changed. However, the model is quite robust, and profit will still remain relatively stable as seen from the sensitivity analysis.

We recommend BlueSky to encourage ‘buy-up’ behavior as it has a net positive effect on profit. ‘Buy-down’ behavior should be discouraged as it has a negative effect on profit. Overbooking strategies should be implemented to counter losses from no-shows according to results from the model. Parameters generated from simulation can only be guaranteed as optimal if model parameters stay unchanged. BlueSky should run the simulation again once market conditions differ (i.e. change in low-fare demand) and change the protection level and virtual capacity to new the outputs to maintain maximal profit.

# References

[1] Bertsimas, D., & de Boer, S. (2005). Simulation-based booking limits for airline revenue management. Operations Research, 53(1), 90-106. https://doi.org/10.1287/opre.1040.0164
[2] Shumsky, R. A. (2009). Case series-BlueSky airlines: Single-leg revenue management. Transactions on Education, 9(3), 140-144. https://doi.org/10.1287/ited.1090.0033cs1